Od zera do SID playera

Hank

(Czyli: gramy prawdziwe zaki)

Projekt, który zamierzam opisać, jest hobbystycznym przedsięwzięciem, który ze zwykłej idei, ewoluując, po wielu potknięciach i pokonaniu wielu problemów, przybrał w końcu swą materialną formę. Projekt ten jednocześnie jest rodzajem PoC (ang. proof of concept), zwłaszcza że brakuje mu pięknie wytrawionej płytki oraz zgrabnej obudowy. Ale po kolei.

Pod adresem https://github.com/hankdraco/realsidplayer można poznać więcej technicznych szczegółów, tymczasem tutaj chciałbym jedynie nakreślić temat oraz napisać kilka słów od siebie.

Wstęp

Zawsze chciałem wykonać jakiś układ z SID-em. Na YT pełno jest podobnych projektów, których ich twórcom po cichu trochę zazdrościłem. :) Wszystkie jednak miały wspólną cechę, która według mnie była wadą – dane wysyłane do SID-a nie pochodziły w czasie rzeczywistym z playera, lecz były zapisaną sekwencją wartości rejestrów SID-a.

Postanowiłem więc spróbować napisać emulator rozkazów procesora 6502, który działałby na mikrokontrolerze i potrafił odegrać zaka.

Tak się jakoś złożyło, że mniej więcej w tym czasie uwagę swoją zwróciłem ku AVR-om. A że akurat posiadałem płytkę Arduino i mruganie diodami czy też scrolle na LCD zwyczajnie mnie znudziły, to okazja pojawiła się sama.

Początkowo planowałem poprzestać na zapisie czegokolwiek do SID-a tak, by wydał jakikolwiek odgłos, lecz wiadomo, że apetyt rośnie w miarę jedzenia. A ten wzrósł, jeszcze zanim zdążyłem przystąpić do właściwej pracy. Wstępne rozeznanie w tym temacie spowodowało już, że połknąłem bakcyla. Dodatkowo, gdy zacząłem się zastanawiać „a właściwie czemu nie spróbować odegrać prawdziwego zaka?”, byłem już ugotowany na dobre. Pobieżna analiza nie przekreśliła takiej możliwości, wobec tego ochoczo przystąpiłem do działania.

Zacząłem wcielać mój pomysł w życie za pomocą... Arduino (tego z ATmegą2560 na pokładzie, z zegarem 16 MHz) do taktowania SID-a oraz przetwarzania kodu playera. Bo „przecież 16 MHz bez problemu da sobie radę ze wszystkim, to wszakże jest 16 razy szybciej niż działa nasz komodiusz!”... :)

Część z Was prawdopodobnie parsknęła teraz (słusznie) śmiechem, lecz tak - takie były początki. Start bardzo ciekawej, inspirującej i odkrywczej wędrówki, którą mogę opisać jedynie pobieżnie, ze względu na jej rozciągłość w czasie (około roku) oraz ogromną ilość potknięć i problemów do pokonania.

Tymczasem w oczy rzucał się przede wszystkim brak emulatora, więc to od niego postanowiłem zacząć.

Emulator - (fal)start!

Rzecz jasna, tylko asembler wchodził tutaj w grę – głównie ze względu na „czasokrytyczny” charakter całości, gdzie potrzebna jest kontrola nad kodem co do milionowej części sekundy (a tak naprawdę to i jeszcze więcej). Zacząłem bez dbałości ani o synchronizację ani wydajność, byleby działało – na poprawki przyjdzie czas później.

Napisałem więc ‘parser opcodów’ 6502, skupiając się jedynie na logice działania, a pomijając całkowicie kwestie związane z czasem i synchronizacją.

Mając gotowy ‘parser’ należało jakoś zapewnić obsługę strony zerowej oraz stosu. Pamięć danych w ATMEDZE to pełne 8 kB, lecz nie od 0x0000, ale od 0x0200 do 0x21ff. Dwie pierwsze strony są zarezerwowane na rejestry kontrolne etc. i nie można ich używać jako pamięci dla danych. W praktyce wyglądało to tak, że zaka można swobodnie wrzucić od 0x1000 aż do 0x21ff, natomiast trzeba jakoś obsłużyć odwołania w kodzie playera do strony zerowej i stosu, czyli trochę „zwirtualizować” pamięć. A zatem komodorowskie adresy $0000-$01ff nie są tożsame z atmegowymi 0x0000-0x01ff i trzeba to było jakoś obsłużyć.

W efekcie po dodaniu obsługi „wirtualnej” pamięci, wzięciu pod uwagę czasu potrzebnego na sprzętową obsługę przerwań w ATMEDZE, a także czasu potrzebnego na właściwą logikę opcodów playera – wszystko się rozjeżdżało w sposób bezczelnie dokumentny... Linia CLK żyła swoim własnym życiem, szyna adresowa była modyfikowana w niewłaściwych momentach, a za nią i szyna danych. Kompletny chaos. SID czasem coś pisnął, ale to raczej przez przypadek, przeważnie siedział cicho i trudno mu się dziwić.

Po wzięciu ołówka i kartki papieru wykonałem w końcu dokładniejsze obliczenia i stało się jasne, że zwyczajnie nie da się tego zrobić na ATMEDZE ze względu na jej powolność wobec wymagań emulatora. Gdybym od początku wszystko dokładniej policzył, oszczędziłbym sobie tego ślepego zaułka.

Czas to... cykle

Główną przeszkodą okazało się zmieszczenie w czasie trwania jednego cyklu C64 wirtualizacji pamięci, obsługi logiki opcodu i linii sterujących SID-em (CLK, CS oraz obu szyn). Dodatkowo nie tylko zmieszczenie się w czasie było krytycznym warunkiem, lecz także poprawne zsynchronizowanie wykonania kodu playera z zegarem taktującym linię CLK SID-a. Wyszło na jaw, że to nie jest takie proste, jak mogłoby się początkowo wydawać.

(Procesor C64 w wersji PAL taktowany jest sygnałem zegarowym o częstotliwości równej dokładnie 0,985248 MHz. Załóżmy jednak, że jest to równe 1 MHz.)

Początkowo zdawało mi się, że szesnastokrotność częstotliwości, którą chcę taktować SID-a, jest w pełni zadowalająca – wszak na jeden cykl C64 przypada aż 16 cykli ATMEGI. Takie podejście nie miało najmniejszej szansy, by działać prawidłowo. Jak już wcześniej napisałem, w efekcie wszystko spóźniało się, potykało i gubiło synchronizację.

Pochyliłem się więc mocno nad zagadnieniem wydajności mojego emulatora i doszedłem do wniosku, że trzeba przesiąść się na coś mocniejszego.

Postanowiłem więc wnieść dwie kluczowe zmiany. Pierwsza to przesiadka z ATmega2560 na ATxmega128A3U – ta druga jest dwukrotnie szybsza (według danych z noty katalogowej), więc z 16 MHz zrobiło się 32 MHz. A tak naprawdę można ją bezpiecznie podkręcić ponad dwukrotnie (niektórzy ponoć dotarli do granic stabilności przy 88 MHz oraz niezmienionym zasilaniu 3,3 V). Osobiście sprawdziłem empirycznie i potwierdzam, że przy 64 MHz ATxmega128A3U działa bezproblemowo. Nie grzeje się nawet podczas kilkugodzinnych sesji. Druga zmiana polegała na znaczącej przebudowie kodu emulatora.

Organizacja pamięci

Mikrokontroler ATxmega128A3U posiada na pokładzie 8 kB pamięci RAM w przestrzeni adresowej od 0x2000 do 0x3fff. Poniżej adresu 0x2000 znajdują się rejestry sterujące pracą hosta, konfiguracją portów, timerów etc., dlatego nie można tego obszaru wykorzystać do przechowywania danych zaka. Większość zaków/playerów rezyduje w pamięci od adresu $1000, co w oczywisty sposób koliduje z organizacją pamięci ATxmega128A3U. Wymusza to zastosowanie programowego obejścia, co niestety wiąże się z dodatkową komplikacją kodu – czyli „wirtualizacją” pamięci.

Podobny problem był w przypadku ATMEGI, podczas obsługi adresowania strony zerowej oraz stosu. Tutaj, czyli w przypadku ATxmega128A3U, zachodzi potrzeba stworzenia wirtualnej przestrzeni adresowej w całej jej rozciągłości, nie tylko dla pierwszych dwóch stron pamięci. Początkowo mogłoby się wydawać, że jest to coś „złego”, lecz tak naprawdę unifikuje to kod, gdyż odtąd każdy adres $C64 musi być przełożony na adres 0xXMEGA i vice versa. I pomimo narzutu czasowego (czyt.: dodatkowe cykle) uprościło to bardzo kod, ponieważ adresowanie można było odtąd zacząć traktować w jednakowy sposób, niezależnie od docelowego adresu.

Organizacja pamięci i możliwości taktowania to jednak nie wszystko, co w oczywisty sposób różni oba mikrokontrolery. Pozostało jeszcze:

Zasilanie

Po przesiadce na ATxmega128A3U pojawił się wymóg konwersji napięć. ATmega2560 działa w logice 5 V - tak samo jak SID. ATxmega128A3U natomiast, jako układ, w którym postawiono na oszczędność energii, zasilana jest napięciem 3,3 V. Stało się to niewygodne z tego względu, że tak, jak przy ATMEDZE mogłem bezpośrednio łączyć linie SID-a z mikrokontrolerem, tak w przypadku XMEGI konieczne było użycie konwerterów poziomów logicznych, co w znaczący sposób pomnożyło ilość kabli i połączeń.

W efekcie układ potrzebował już trzech różnych napięć: 9 V (lub 12 V w przypadku „starego” SID-a), 5 V oraz 3,3 V.

Wyjście audio

Chcąc uzyskać efekt brzmienia SID-a możliwie zbliżony do tego, którym raczy nas oryginalny C64, postanowiłem użyć takich samych elementów do budowy przedwzmacniacza, jakie były fabrycznie montowane w prawdziwych C64. Właściwie nawet nie tyle takich samych, co tych samych - posiadając uszkodzoną płytę C64 (REV.B) postanowiłem choć niektórym elementom podarować drugie życie.

Jeśli mowa o brzmieniu, to nie sposób nie wspomnieć o dwóch, kluczowych dla obwodu filtrów SID-a, kondensatorach. Mają one naprawdę ogromny wpływ na brzmienie, a jak bardzo ogromny, mogłem przekonać się używając początkowo kondensatorów o wartościach 10 razy mniejszych (akurat takie miałem pod ręką - 2,2 nF) niż podane w nocie katalogowej SID-a. Ich wymiana na te wylutowane z oryginalnej płyty C64 zaowocowała zdecydowanie cieplejszym dźwiękiem, już znacznie bardziej „komodorowskim”.

Linia EXT-IN a przydźwięk

Jak powszechnie wiadomo, układ SID pozwala poddawać filtracji sygnał audio doprowadzony z zewnątrz. Gdy linia ta jest nieużywana (czyt.: niepodłączona), generuje zakłócenia, które są następnie przekazywane na wyjście. Można je usłyszeć, zwłaszcza kiedy SID nic nie gra - objawiają się jako specyficzne bzyczenie/buczenie/trzeszczenie w zależności od tego, na jakiego rodzaju zakłócenia elektromagnetyczne jest wystawiony SID. W swym rodzimym środowisku układ ten jest zamknięty w obudowie komputera, wewnątrz której zastosowano dodatkową ochronę przed wpływem zewnętrznych pól elektromagnetycznych w postaci pokrytej przewodzącym materiałem tektury, owiniętej wokół płyty głównej. Tworzy ona ekran, który „zbiera” wszelkie elektromagnetyczne zanieczyszczenia z zewnątrz i odprowadza od razu do masy.

Jeśli więc teraz wyjąć SID-a z jego naturalnego środowiska, wpiąć go w płytkę prototypową i obudować przewodami, to obrazowo można powiedzieć, że EXT-IN zachowa się jak antena odbierająca to, co te przewody (będące również antenami) emitują. W skrócie: nie ma ekranu, nie ma odprowadzania zakłóceń.

Remedium na ten problem jest podpięcie EXT-IN do masy przez jakiś drobny kondensator ceramiczny i rezystor w szeregu (nie polecam zwierania bezpośrednio EXT-IN - GND). Ja zrealizowałem połączenie wejścia audio z masą poprzez właśnie kondensator i rezystor.

Taka ciekawostka: słyszałem, że aby ograniczyć tego typu zakłócenia w realnym C64 ludzie zwierają bezpośrednio EXT-IN do masy poprzez gniazdo AUDIO/VIDEO (piny 5 i 2). Sposób może i działa, ale ja polecałbym użycie dodatkowego rezystora między pinami 5 i 2, ponieważ po drodze od pinu 2 (masa) do pinu 5 (EXT-IN) gniazda AUDIO/VIDEO jest w szeregu tylko kondensator - brak rezystora oznacza brak ograniczenia prądu ładowania tego kondensatora w chwili pojawienia się napięcia, co z kolei przekłada się na duże obciążenie EXT-IN w początkowych chwilach ładowania. Kondensator jest wprawdzie niewielki i rezystancja samych połączeń i przewodów wystarcza, ale... :)

Jeszcze raz o emulatorze

Technicznie rzecz biorąc obecnie obsługiwane są jedynie „legalne” rozkazy (aczkolwiek we wszystkich trybach adresowania), zaniechałem obsługi przerwań oraz trybu BCD. Czuję się jednak usprawiedliwiony, ponieważ moim celem nie było modelowanie działania całego procesora 6502, ale wyłącznie możliwość odegrania zaków - a ten cel został osiągnięty. Być może istnieją jakieś egzotyczne playery używające trybu BCD, jednak póki co na żaden taki nie trafiłem podczas testów. Oczywistym jest także, że nie wszystkie zaki ten „emulator” potrafi odtworzyć. Ograniczenia biorą się również z limitów ilości wbudowanej w mikrokontroler pamięci RAM. Bo np. funkcjonalnie nie ma problemu z odegraniem sampli, jednak ze względu na małą ilość pamięci RAM trudno te sample w niej zmieścić.

A zatem, jak całość działa? W obecnej wersji zak w postaci kodu C64 siedzi sobie w pamięci FLASH wraz z kodem przeznaczonym dla samego hosta. Dodatkowo potrzebna jest również podprocedura dla „emulatora”, która zainicjuje zaka skacząc pod $1000 oraz cyklicznie będzie wykonywać skoki pod $1003.

Po resecie XMEGI zak wraz z procedurą odtwarzającą przepisywany jest z pamięci FLASH do RAM-u, następnie ustawiane są wszystkie linie sterujące, porty, przerwania oraz przeprowadzana jest sprzętowa inicjalizacja SID-a. Po tym wszystkim sterowanie oddawane jest emulatorowi, który ustawia adres startowy i przystępuje do działania. A tak wygląda podstawowy kod odgrywający zaka, tu w wersji „raz na ramkę”:

lda #$00 ldx #$00 ldy #$00 jsr $1000 loop lda $00 ; -prawie- odpowiednik $d012 bne loop jsr $1003 lda #$00 beq loop

Zdawać by się mogło, że skoro nie ma przerwań (a zwłaszcza przerwań rastra z VIC-a), to wystarczy zastosować prostą pętlę opóźniającą, by odtwarzać zaka równo raz na ramkę.

Kod playera zabiera jednak różny czas rastrowy w każdej ramce. Raz będzie potrzebował np. 20 linii, a w następnej 15. W kolejnej 23 i pół. Chcąc wykorzystać pętlę opóźniającą trzeba by w każdej ramce jakoś kompensować czas, który zużył player i na bieżąco obliczać wartość opóźnienia, by „dobić” do pełnej ramki, co jest oczywistym absurdem. Jeśli by wyobrazić sobie taki kod uruchomiony na realnym C64 lub nawet emulatorze, to stałe opóźnienie spowoduje, że zak będzie „pływał” po ekranie, z prędkością i kierunkiem zależnym od czasu, jaki akurat zjadł player i jakie zastosowano opóźnienie.

Wpadłem więc na lepszy pomysł - zasymulowałem coś na wzór numeru aktualnej linii rastra w komórce $0000 (ten adres wydał mi się całkiem niezły, zwłaszcza, że jest mało prawdopodobne, by którykolwiek player miał z tą komórką cokolwiek robić). Mechanizm zliczania linii rastrowych w swym ogólnym założeniu oparty jest na zliczaniu cykli C64. Wiedząc, że jedna linia rastrowa ma ich 63, można precyzyjnie odmierzać całe linie.

Jednak ja zastosowałem pewne uproszczenie. W systemie PAL linii jest 312. Jest to trochę więcej, aniżeli potrafi pomieścić jeden bajt – aby więc korzystać z dobrodziejstw precyzji co do jednej linii, trzeba by zastosować dwubajtowy licznik, co z kolei wiąże się z dodatkowymi operacjami (a więc i cennymi cyklami) hosta, których jest jedynie 64 w jednym cyklu C64. Uznałem więc, że można poświęcić taką dokładność co do pojedynczej linii i zamiast 312 63-cyklowych linii zasymulowałem 156 linii po 126 cykli. Innymi słowy, co 126 cykli C64 inkrementuję wartość w komórce $0000, po czym po 156 „liniach” resetuję ten licznik do zera. Efekt tego całego zamieszania jest taki, że komórka $0000 jest zwiększana co dwie linie rastra. Dlatego właśnie jest to „prawie”, a nie zwyczajny odpowiednik $d012.

Dzięki temu zabiegowi, bez względu na to, ile czasu zabierze player, i tak zostanie zachowany odstęp dokładnie jednej ramki pomiędzy kolejnymi wywołaniami (z dokładnością do kilku cykli C64). To uproszczenie nie zadziała prawidłowo tylko w przypadku playera z czasem rastrowym krótszym niż dwie linie (co prawda długie, lecz zawsze tylko dwie), ale póki co nie słyszałem o takich.

Podsumowanie

Tym artykułem udało mi się jedynie pobieżnie zahaczyć ten szeroki i jak bardzo interesujący temat wzajemnej współpracy SID-a z mikrokontrolerem. Wielu kwestii nie udało mi się wyczerpująco opisać, o innych nawet wspomnieć.

Wszystkich zainteresowanych zapraszam na https://github.com/hankdraco/realsidplayer, gdzie znajduje się więcej szczegółów, a także cały kod emulatora, projekt hosta oraz schemat układu wraz ze zdjęciami i zgranymi zakami w postaci plików .mp3.

Hank/Draco